/*
* Copyright 2014 Bevbot LLC <info@bevbot.com>
*
* This file is part of the Kegtab package from the Kegbot project. For
* more information on Kegtab or Kegbot, see <http://kegbot.org/>.
*
* Kegtab is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free
* Software Foundation, version 2.
*
* Kegtab is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with Kegtab. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kegbot.api;
import android.content.Context;
import android.os.SystemClock;
import android.util.Log;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.io.ByteStreams;
import com.google.protobuf.GeneratedMessage;
import com.google.protobuf.Message.Builder;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;
import org.kegbot.app.KegbotApplication;
import org.kegbot.app.config.AppConfiguration;
import org.kegbot.app.util.TimeSeries;
import org.kegbot.app.util.Utils;
import org.kegbot.app.util.Version;
import org.kegbot.backend.Backend;
import org.kegbot.backend.BackendException;
import org.kegbot.proto.Api.RecordTemperatureRequest;
import org.kegbot.proto.Models;
import org.kegbot.proto.Models.AuthenticationToken;
import org.kegbot.proto.Models.Controller;
import org.kegbot.proto.Models.Drink;
import org.kegbot.proto.Models.FlowMeter;
import org.kegbot.proto.Models.FlowToggle;
import org.kegbot.proto.Models.Image;
import org.kegbot.proto.Models.Keg;
import org.kegbot.proto.Models.KegTap;
import org.kegbot.proto.Models.Session;
import org.kegbot.proto.Models.SoundEvent;
import org.kegbot.proto.Models.SystemEvent;
import org.kegbot.proto.Models.ThermoLog;
import org.kegbot.proto.Models.User;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.URLEncoder;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
public class KegbotApiImpl implements Backend {
private static final String TAG = KegbotApiImpl.class.getSimpleName();
private static final byte[] HYPHENS = {'-', '-'};
private static final byte[] CRLF = {'\r', '\n'};
private final AppConfiguration mConfig;
private final CookieManager mCookieManager;
private final OkHttpClient mClient;
private final String mUserAgent;
public KegbotApiImpl(AppConfiguration config, String userAgent) {
mConfig = config;
mCookieManager = new CookieManager();
mClient = new OkHttpClient();
mClient.setCookieHandler(mCookieManager);
CookieHandler.setDefault(mCookieManager);
mUserAgent = userAgent;
}
public static KegbotApiImpl fromContext(Context context) {
final AppConfiguration config = KegbotApplication.get(context).getConfig();
final String userAgent = Utils.getUserAgent(context);
return new KegbotApiImpl(config, userAgent);
}
private static String getUrlParamsString(Map<String, String> params) {
if (params == null || params.isEmpty()) {
return "";
}
final List<String> parts = Lists.newArrayList();
for (Map.Entry<String, String> param : params.entrySet()) {
try {
parts.add(String.format("%s=%s",
URLEncoder.encode(param.getKey(), "utf-8"),
URLEncoder.encode(param.getValue(), "utf-8")));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
return Joiner.on('&').join(parts);
}
private static RequestBody formBody(Map<String, String> params) {
return RequestBody.create(
MediaType.parse("application/x-www-form-urlencoded"),
getUrlParamsString(params).getBytes());
}
private static String getBoundary() {
return String.format("-------MultiPart%s", new SecureRandom().nextLong());
}
/** Builds a multi-part form body. */
private static RequestBody formBody(Map<String, String> params, Map<String, File> files)
throws KegbotApiException {
final String boundary = getBoundary();
final byte[] boundaryBytes = boundary.getBytes();
final ByteArrayOutputStream bos = new ByteArrayOutputStream();
final byte[] outputBytes;
try {
// Form data.
for (final Map.Entry<String, String> param : params.entrySet()) {
bos.write(HYPHENS);
bos.write(boundaryBytes);
bos.write(CRLF);
bos.write(
String.format("Content-Disposition: form-data; name=\"%s\"", param.getKey()).getBytes());
bos.write(CRLF);
bos.write(CRLF);
bos.write(param.getValue().getBytes());
bos.write(CRLF);
}
// Files
for (final Map.Entry<String, File> entry : files.entrySet()) {
final String entityName = entry.getKey();
final File file = entry.getValue();
bos.write(HYPHENS);
bos.write(boundaryBytes);
bos.write(CRLF);
bos.write(
String.format("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"",
entityName, file.getName()).getBytes()
);
bos.write(CRLF);
bos.write(CRLF);
final FileInputStream fis = new FileInputStream(file);
try {
ByteStreams.copy(fis, bos);
} finally {
fis.close();
}
bos.write(CRLF);
}
bos.write(HYPHENS);
bos.write(boundaryBytes);
bos.write(HYPHENS);
bos.write(CRLF);
bos.flush();
outputBytes = bos.toByteArray();
} catch (IOException e) {
throw new KegbotApiException(e);
}
return RequestBody.create(
MediaType.parse("multipart/form-data;boundary=" + boundary), outputBytes);
}
private String apiUrl() {
return String.format("%s/v1", Strings.nullToEmpty(mConfig.getApiUrl()));
}
private String apiKey() {
return Strings.nullToEmpty(mConfig.getApiKey());
}
@Override
public void start(Context context) {
}
private Request.Builder newRequest(final String path) {
Request.Builder builder = new Request.Builder()
.url(apiUrl() + path)
.addHeader("User-Agent", mUserAgent)
.addHeader("X-Kegbot-Api-Key", apiKey());
return builder;
}
private JsonNode requestJson(Request request) throws KegbotApiException {
final Response response;
final long startTime = SystemClock.elapsedRealtime();
try {
response = mClient.newCall(request).execute();
} catch (IOException e) {
Log.w(TAG, String.format("--> %s %s [ERR]", request.method(), request.urlString()));
throw new KegbotApiException(e);
}
final long endTime = SystemClock.elapsedRealtime();
final int responseCode = response.code();
final String logMessage = String.format("--> %s %s [%s] %sms", request.method(), request.urlString(),
responseCode, endTime - startTime);
if (responseCode >= 200 && responseCode < 300) {
Log.d(TAG, logMessage);
} else {
Log.w(TAG, logMessage);
}
final ResponseBody body = response.body();
final JsonNode rootNode;
try {
try {
final ObjectMapper mapper = new ObjectMapper();
rootNode = mapper.readValue(body.byteStream(), JsonNode.class);
} finally {
body.close();
}
} catch (JsonParseException e) {
throw new KegbotApiMalformedResponseException(e);
} catch (JsonMappingException e) {
throw new KegbotApiMalformedResponseException(e);
} catch (IOException e) {
throw new KegbotApiException(e);
}
boolean success = false;
try {
// Handle structural errors.
if (!rootNode.has("meta")) {
throw new KegbotApiMalformedResponseException("Response is missing 'meta' field.");
}
final JsonNode meta = rootNode.get("meta");
if (!meta.isContainerNode()) {
throw new KegbotApiMalformedResponseException("'meta' field is wrong type.");
}
final String message;
if (rootNode.has("error") && rootNode.get("error").has("message")) {
message = rootNode.get("error").get("message").getTextValue();
} else {
message = null;
}
// Handle HTTP errors.
if (responseCode < 200 || responseCode >= 400) {
switch (responseCode) {
case 401:
throw new NotAuthorizedException(message);
case 404:
throw new KegbotApi404(message);
case 405:
throw new MethodNotAllowedException(message);
default:
if (message != null) {
throw new KegbotApiServerError(message);
} else {
throw new KegbotApiServerError("Server error, response code=" + responseCode);
}
}
}
success = true;
return rootNode;
} finally {
if (!success) {
Log.d(TAG, "Response JSON was: " + rootNode.toString());
}
}
}
private JsonNode getJson(String path) throws KegbotApiException {
final Request.Builder request = newRequest(path);
return requestJson(request.build());
}
private JsonNode postJson(String path, Map<String, String> params) throws KegbotApiException {
final Request.Builder request = newRequest(path).
post(formBody(params));
return requestJson(request.build());
}
private JsonNode deleteJson(String path) throws KegbotApiException {
final Request.Builder request = newRequest(path).delete();
return requestJson(request.build());
}
private JsonNode postJson(String path, Map<String, String> params,
Map<String, File> files) throws KegbotApiException {
final Request.Builder request = newRequest(path).post(formBody(params, files));
return requestJson(request.build());
}
private <T extends GeneratedMessage> List<T> getProto(String path, Builder builder) throws KegbotApiException {
JsonNode result = getJson(path);
if (result.has("object")) {
final T resultMessage = getSingleProto(builder, result.get("object"));
return Collections.singletonList(resultMessage);
} else {
final List<T> results = Lists.newArrayList();
final JsonNode objects = result.get("objects");
final Iterator<JsonNode> iter = objects.getElements();
while (iter.hasNext()) {
@SuppressWarnings("unchecked")
final T res = (T) getSingleProto(builder, iter.next());
results.add(res);
}
return results;
}
}
private <T extends GeneratedMessage> T getSingleProto(String path, Builder builder)
throws KegbotApiException {
JsonNode result = getJson(path);
return getSingleProto(builder, result.get("object"));
}
private <T extends GeneratedMessage> T getSingleProto(Builder builder, JsonNode root) {
builder.clear();
@SuppressWarnings("unchecked")
final T result = (T) ProtoEncoder.toProto(builder, root).build();
return result;
}
private <T extends GeneratedMessage> T postProto(String path, Builder builder, Map<String, String> params)
throws KegbotApiException {
JsonNode result = postJson(path, params);
return getSingleProto(builder, result.get("object"));
}
private <T extends GeneratedMessage> T postProto(String path, Builder builder,
Map<String, String> params, Map<String, File> files) throws KegbotApiException {
JsonNode result = postJson(path, params, files);
return getSingleProto(builder, result.get("object"));
}
public Version getVersion() throws KegbotApiException {
final String version = getJson("/version").get("object").get("server_version").getTextValue();
return Version.fromString(version);
}
public boolean supportsDeviceLink() throws KegbotApiException {
try {
return getVersion().compareTo(Version.fromString("0.9.27")) >= 0;
} catch (KegbotApi404 e) {
return false;
}
}
public String startDeviceLink(final String deviceName) throws KegbotApiException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("name", deviceName);
debug("Starting device link ...");
final JsonNode result = postJson("/devices/link/", params);
final String code = result.get("object").get("code").getTextValue();
if (Strings.isNullOrEmpty(code)) {
throw new KegbotApiException("Pairing code was empty.");
}
return code;
}
/** Returns an apikey once linked, {@code null} otherwise. */
public String pollDeviceLink(final String code) throws KegbotApiException {
final JsonNode response = getJson("/devices/link/status/" + code).get("object");
if (response.has("linked") && response.get("linked").getBooleanValue()) {
return response.get("api_key").getTextValue();
}
return null;
}
@Deprecated
public void login(String username, String password) throws KegbotApiException {
Map<String, String> params = Maps.newLinkedHashMap();
params.put("username", username);
params.put("password", password);
debug("Logging in as username=" + username + " password=XXX");
postJson("/login/", params);
}
@Deprecated
public String getApiKey() throws KegbotApiException {
final JsonNode result = getJson("/get-api-key/");
final JsonNode rootNode = result.get("object");
if (rootNode == null) {
throw new KegbotApiServerError("Invalid response.");
}
final JsonNode keyNode = rootNode.get("api_key");
if (keyNode == null) {
throw new KegbotApiServerError("Invalid response.");
}
final String apiKey = keyNode.getValueAsText();
debug("Got api key:" + apiKey);
mConfig.setApiKey(apiKey);
return apiKey();
}
@Override
public List<SoundEvent> getSoundEvents() throws KegbotApiException {
return getProto("/sound-events/", SoundEvent.newBuilder());
}
@Override
public List<KegTap> getTaps() throws KegbotApiException {
return getProto("/taps/", KegTap.newBuilder());
}
@Override
public KegTap createTap(String tapName) throws BackendException {
final Map<String, String> params = ImmutableMap.<String, String>builder()
.put("name", tapName)
.build();
return postProto("/taps/", KegTap.newBuilder(), params);
}
@Override
public void deleteTap(KegTap tap) throws KegbotApiException {
final String path = "/taps/" + tap.getId();
deleteJson(path);
}
@Override
public AuthenticationToken getAuthToken(String authDevice, String tokenValue)
throws KegbotApiException {
try {
authDevice = URLEncoder.encode(authDevice, Charsets.UTF_8.name());
tokenValue = URLEncoder.encode(tokenValue, Charsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
try {
return getSingleProto("/auth-tokens/" + authDevice + "/" + tokenValue + "/",
AuthenticationToken.newBuilder());
} catch (KegbotApi404 e) {
return null;
}
}
@Override
public Keg endKeg(Keg keg) throws KegbotApiException {
return (Keg) postProto("/kegs/" + keg.getId() + "/end/", Keg.newBuilder(), null);
}
@Override
public KegTap startKeg(KegTap tap, String beerName, String brewerName, String styleName,
String kegType) throws KegbotApiException {
final Map<String, String> params = ImmutableMap.<String, String>builder()
.put("beer_name", beerName)
.put("brewer_name", brewerName)
.put("style_name", styleName)
.put("keg_size", kegType)
.build();
return postProto("/taps/" + tap.getId() + "/activate/", KegTap.newBuilder(), params);
}
@Override
public List<SystemEvent> getEvents() throws KegbotApiException {
return getProto("/events/", SystemEvent.newBuilder());
}
@Override
public List<SystemEvent> getEventsSince(final long sinceEventId) throws KegbotApiException {
return getProto("/events/?since=" + sinceEventId, SystemEvent.newBuilder());
}
@Override
public JsonNode getSessionStats(int sessionId) throws KegbotApiException {
return getJson("/sessions/" + sessionId + "/stats/").get("object");
}
@Override
public FlowMeter calibrateMeter(FlowMeter meter, double ticksPerMl) throws BackendException {
final Map<String, String> params = ImmutableMap.<String, String>builder()
.put("ticks_per_ml", Double.valueOf(ticksPerMl).toString())
.put("ml_per_tick", Double.valueOf(1.0 / ticksPerMl).toString())
.build();
return postProto("/flow-meters/" + meter.getId(), FlowMeter.newBuilder(), params);
}
@Override
public User getUser(String username) throws KegbotApiException {
return getSingleProto("/users/" + username, User.newBuilder());
}
@Override
public List<User> getUsers() throws KegbotApiException {
return getProto("/users/", User.newBuilder());
}
@Override
public Session getCurrentSession() throws KegbotApiException {
final List<Session> sessions = getProto("/sessions/?limit=1", Session.newBuilder());
if (sessions.isEmpty()) {
return null;
}
final Session session = sessions.get(0);
if (session.getIsActive()) {
return session;
}
return null;
}
@Override
public Drink recordDrink(String tapName, long volumeMl, long ticks,
@Nullable String shout, @Nullable String username, @Nullable String recordDate, long durationMillis,
@Nullable TimeSeries timeSeries, @Nullable File picture) throws BackendException {
final ImmutableMap.Builder<String, String> paramBuilder = ImmutableMap.builder();
paramBuilder.put("ticks", String.valueOf(ticks));
if (volumeMl > 0) {
paramBuilder.put("volume_ml", String.valueOf(volumeMl));
}
if (!Strings.isNullOrEmpty(username)) {
paramBuilder.put("username", username);
}
if (!Strings.isNullOrEmpty(recordDate)) {
try {
paramBuilder.put("record_date", recordDate); // new API
} catch (IllegalArgumentException e) {
// Ignore.
}
}
if (durationMillis > 0) {
// TODO: Fix API to report this in millis.
paramBuilder.put("duration", String.valueOf(durationMillis / 1000));
}
// TODO: Handle spilled.
if (!Strings.isNullOrEmpty(shout)) {
paramBuilder.put("shout", shout);
}
if (timeSeries != null) {
paramBuilder.put("tick_time_series", timeSeries.asString());
}
if (picture != null) {
final Map<String, File> files = ImmutableMap.of("photo", picture);
return postProto("/taps/" + tapName, Drink.newBuilder(), paramBuilder.build(), files);
} else {
return postProto("/taps/" + tapName, Drink.newBuilder(), paramBuilder.build());
}
}
@Override
public ThermoLog recordTemperature(final RecordTemperatureRequest request)
throws KegbotApiException {
if (!request.isInitialized()) {
throw new KegbotApiException("Request is missing required field(s)");
}
final String sensorName = request.getSensorName();
final String sensorValue = String.valueOf(request.getTempC());
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("temp_c", sensorValue);
return (ThermoLog) postProto("/thermo-sensors/" + sensorName, ThermoLog.newBuilder(), params);
}
@Override
public Image attachPictureToDrink(int drinkId, File picture) throws KegbotApiException {
final String url = String.format("/drinks/%s/add-photo/", Integer.valueOf(drinkId));
final Map<String, File> files = ImmutableMap.of("photo", picture);
return postProto(url, Image.newBuilder(), null, files);
}
@Override
public User createUser(String username, String email, String password, String imagePath)
throws KegbotApiException {
final ImmutableMap.Builder<String, String> builder = ImmutableMap.<String, String>builder()
.put("username", username)
.put("email", email)
.put("accepted_terms", "true");
if (!Strings.isNullOrEmpty(password)) {
builder.put("password", password);
}
if (!Strings.isNullOrEmpty(imagePath)) {
final Map<String, File> files = ImmutableMap.of("photo", new File(imagePath));
return postProto("/new-user/", User.newBuilder(), builder.build(), files);
} else {
return postProto("/new-user/", User.newBuilder(), builder.build());
}
}
@Override
public AuthenticationToken assignToken(String authDevice, String tokenValue, String username)
throws KegbotApiException {
final String url = "/auth-tokens/" + authDevice + "/" + tokenValue + "/assign/";
final Map<String, String> params = ImmutableMap.<String, String>builder()
.put("username", username)
.build();
return postProto(url, AuthenticationToken.newBuilder(), params);
}
@Override
public List<Controller> getControllers() throws BackendException {
return getProto("/controllers/", Controller.newBuilder());
}
@Override
public Controller updateController(Controller controller) throws BackendException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("name", controller.getName());
if (controller.hasSerialNumber()) {
params.put("serial_number", controller.getSerialNumber());
}
if (controller.hasModelName()) {
params.put("model_name", controller.getModelName());
}
return (Controller) postProto("/controllers/" + controller.getId(), Controller.newBuilder(),
params);
}
@Override
public List<FlowMeter> getFlowMeters() throws BackendException {
return getProto("/flow-meters/", FlowMeter.newBuilder());
}
@Override
public FlowMeter updateFlowMeter(FlowMeter flowMeter) throws BackendException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("port_name", flowMeter.getPortName());
return (FlowMeter) postProto("/flow-meters/" + flowMeter.getId(), FlowMeter.newBuilder(),
params);
}
private synchronized void debug(String message) {
Log.d(TAG, message);
}
@Override
public Controller createController(String name, String serialNumber, String deviceType)
throws BackendException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("name", name);
if (!Strings.isNullOrEmpty(serialNumber)) {
params.put("serial_number", serialNumber);
}
if (!Strings.isNullOrEmpty(deviceType)) {
params.put("model_name", deviceType);
}
return (Controller) postProto("/controllers/", Controller.newBuilder(), params);
}
@Override
public FlowMeter createFlowMeter(Controller controller, String portName, double ticksPerMl) throws BackendException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("controller", String.valueOf(controller.getId()));
params.put("port_name", portName);
params.put("ticks_per_ml", String.valueOf(ticksPerMl));
return (FlowMeter) postProto("/flow-meters/", FlowMeter.newBuilder(), params);
}
@Override
public KegTap connectMeter(KegTap tap, FlowMeter meter) throws BackendException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("meter", String.valueOf(meter.getId()));
return (KegTap) postProto("/taps/" + tap.getId() + "/connect-meter", KegTap.newBuilder(), params);
}
@Override
public KegTap disconnectMeter(KegTap tap) throws BackendException {
return (KegTap) postProto("/taps/" + tap.getId() + "/disconnect-meter", KegTap.newBuilder(), null);
}
@Override
public List<FlowToggle> getFlowToggles() throws BackendException {
return getProto("/flow-toggles/", FlowToggle.newBuilder());
}
@Override
public Models.FlowToggle updateFlowToggle(Models.FlowToggle flowToggle) throws BackendException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("port_name", flowToggle.getPortName());
return (FlowToggle) postProto("/flow-toggles/" + flowToggle.getId(), FlowToggle.newBuilder(),
params);
}
@Override
public KegTap connectToggle(KegTap tap, Models.FlowToggle toggle) throws BackendException {
final Map<String, String> params = Maps.newLinkedHashMap();
params.put("toggle", String.valueOf(toggle.getId()));
return (KegTap) postProto("/taps/" + tap.getId() + "/connect-toggle", KegTap.newBuilder(), params);
}
@Override
public KegTap disconnectToggle(KegTap tap) throws BackendException {
return (KegTap) postProto("/taps/" + tap.getId() + "/disconnect-toggle", KegTap.newBuilder(),
null);
}
}